CRITICAL · OutOfMemoryError Risk

Android
Memory
Leaks

Objects that should be dead, kept alive by forgotten references. A complete guide to understanding, detecting, and eliminating every category of memory leak in Android.

~30%
of ANRs linked to OOM
12+
leak categories covered
0
acceptable leaks in prod
01 · Foundation

Android Memory Model

Android runs each app in a sandboxed Dalvik/ART process with a fixed heap limit. This limit varies by device — from 32MB on older low-end devices to 512MB+ on modern flagships. Cross this limit and the OS throws an OutOfMemoryError, immediately crashing your app.

The heap is divided into generations. The Young generation (Eden + Survivor spaces) holds short-lived objects. Most objects die young — collected quickly and cheaply. The Old generation holds long-lived objects. GC here is expensive and pauses the world. Memory leaks accumulate in the Old generation.

Young Generation — short-lived
Eden space
new objects born here
Survivor S0 / S1
survived 1 GC
Minor GC
fast, frequent
Old Generation — leak zone
Tenured objects
survived many GCs
Leaked Activities
should be dead
Major GC
slow, stop-the-world
Special references
WeakReference
collected if only ref
SoftReference
collected on OOM
PhantomReference
post-finalization
ART vs Dalvik
ART (Android 5+)
AOT + JIT, concurrent GC
Concurrent GC
runs alongside app
getMemoryClass()
your heap limit

The heap limit is per-process, not per-app. If you use android:process to run components in separate processes, each process gets its own heap limit. But this also means they can't share memory directly — another source of overhead to consider.

02 · Garbage Collection

GC, Roots & Reachability

The garbage collector works by tracing a graph of object references starting from GC Roots. Any object reachable from a GC root is considered alive and will not be collected. Memory leaks occur when an object you consider logically "dead" is still reachable from a GC root through a chain of references.

Definition: A memory leak is an object that is no longer needed by your application logic, but is still reachable from a GC root. The GC cannot collect it. It accumulates. Eventually your heap fills up and the app crashes with OutOfMemoryError.

GC Roots in Android

Local variables on the stack of any running thread
Static fields of any class — live for the entire process lifetime
JNI references held by native code
Running threads themselves — and every object they reference
System classes — the class loader keeps all loaded classes alive

The leak chain always has the same structure: GC Root → long-lived object → leaked object. For example: static fieldSingletonActivity. The Activity is done. The user rotated the screen. But the Singleton holds a reference. The GC traces from the static field, reaches the Singleton, reaches the Activity — and marks it alive. The Activity's entire view hierarchy stays in memory. 50MB gone per rotation.

03 · Leak Type

Static Reference Leaks

Static fields live for the lifetime of the process. Anything you store in a static field — or in a singleton backed by a static field — must never hold a reference to an Activity, Fragment, View, or Context. These UI objects have lifecycle-bound lifetimes. Static fields do not.

Static reference leaks
LEAK
// ✗ Static Activity reference — rotates once = 1 leaked Activity + full view hierarchy
object AnalyticsManager {
    var currentActivity: Activity? = null  // LEAKS on rotation
}

// ✗ Static View reference — View holds Context (Activity)
companion object {
    private var cachedView: View? = null  // entire window hierarchy kept alive
}

// ✗ Singleton holding Application context used as Activity context
class ImageLoader private constructor(val context: Context) {
    companion object {
        fun init(context: Context) = ImageLoader(context)  // pass Activity accidentally
    }
}
Fixes
FIX
// ✓ Never store Activity in static field. Use callbacks or WeakReference.
object AnalyticsManager {
    private var activityRef: WeakReference<Activity>? = null
    fun attach(a: Activity) { activityRef = WeakReference(a) }
    fun detach() { activityRef = null }  // call in onDestroy
}

// ✓ Singletons should use Application context, never Activity context
class ImageLoader private constructor(val context: Context) {
    companion object {
        fun init(context: Context) = ImageLoader(context.applicationContext)
    }
}
04 · Leak Type

Inner Class & Anonymous Class Leaks

Non-static inner classes and anonymous classes hold an implicit reference to their enclosing outer class. If the inner class outlives the outer class — by being posted to a Handler, passed to a background thread, or stored in a callback — the outer class (often an Activity) is leaked.

Inner class & Handler leaks
LEAK
class SplashActivity : AppCompatActivity() {

    // ✗ Non-static inner class — holds implicit ref to SplashActivity
    inner class SplashHandler : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) { navigateToMain() }
    }
    private val handler = SplashHandler()  // queued messages keep Activity alive

    // ✗ Anonymous Runnable — captures 'this' (Activity)
    private fun startTimer() {
        Handler.postDelayed({ doSomething() }, 5000)  // lambda captures Activity
    }
}
Fixes
FIX
// ✓ Static inner class + WeakReference to outer
class SplashActivity : AppCompatActivity() {

    private class SplashHandler(activity: SplashActivity) :
            Handler(Looper.getMainLooper()) {
        private val ref = WeakReference(activity)
        override fun handleMessage(msg: Message) {
            ref.get()?.navigateToMain()  // safe: Activity may be null
        }
    }

    // ✓ Modern: use lifecycleScope instead of Handler entirely
    private fun startTimer() {
        lifecycleScope.launch {
            delay(5000)
            navigateToMain()
        }  // cancelled automatically in onDestroy
    }
}
💡

Modern advice: Avoid Handler entirely for delayed work in Activities and Fragments. Use lifecycleScope.launch { delay(ms); doWork() } — it's automatically cancelled when the lifecycle is destroyed, no manual cleanup needed.

05 · Leak Type

Context Leaks

Context is the single most leaked object type in Android. There are two fundamentally different Context types and choosing the wrong one in a long-lived object causes leaks every time.

Application Context — safe for long-lived objects
context.applicationContext
lives for process lifetime
Singletons
safe to store
Repositories
safe to store
ViewModels
safe to store
Activity Context — never store long-term
Activity / Fragment
destroyed on rotation
Views
hold Activity ref
DataBinding
must be cleared
Dialogs (unclosed)
hold window token
Context leak patterns
LEAK
// ✗ ViewModel storing Activity context
class BadViewModel(val context: Context) : ViewModel() {
    // ViewModel outlives Activity on rotation — Activity leaked!
    fun loadImage() = Glide.with(context).load(url)  // context = Activity
}

// ✗ Custom View caching a Context in a static field
class ThemeHelper {
    companion object {
        var ctx: Context? = null  // set to Activity, never cleared
    }
}

// ✗ Dialog not dismissed — holds Activity window token
class MyActivity : AppCompatActivity() {
    private var dialog: AlertDialog? = null
    override fun onDestroy() {  // dialog showing when Activity dies
        super.onDestroy()  // dialog.dismiss() not called!
    }
}
Fixes
FIX
// ✓ ViewModel using Application context via AndroidViewModel or Hilt
@HiltViewModel
class GoodViewModel @Inject constructor(
    @ApplicationContext private val context: Context  // Application context, safe
) : ViewModel()

// ✓ Always dismiss dialogs in onDestroy
override fun onDestroy() {
    dialog?.dismiss()
    dialog = null
    super.onDestroy()
}
06 · Leak Type

Listener & Callback Leaks

Registering a listener creates a reference from the system (or a long-lived object) to your Activity/Fragment. If you forget to unregister, that reference keeps your UI alive long after it should be gone. This is extremely common with broadcast receivers, location managers, sensor managers, and custom event buses.

Listener leak patterns
LEAK
class MainActivity : AppCompatActivity() {

    // ✗ BroadcastReceiver registered, never unregistered
    private val receiver = object : BroadcastReceiver() {
        override fun onReceive(ctx: Context, intent: Intent) { handleBroadcast() }
    }
    override fun onResume() {
        super.onResume()
        registerReceiver(receiver, IntentFilter("ACTION"))  // registered...
    }
    // ... but onPause/onDestroy never calls unregisterReceiver()

    // ✗ LocationManager listener — callback holds Activity reference
    override fun onStart() {
        locationManager.requestLocationUpdates(provider, 0, 0f, locationListener)
    }
    // Missing: locationManager.removeUpdates(locationListener) in onStop()
}
Symmetric register/unregister pattern
FIX
class MainActivity : AppCompatActivity() {

    // ✓ Symmetric: register in onStart, unregister in onStop
    override fun onStart() {
        super.onStart()
        registerReceiver(receiver, filter)
        locationManager.requestLocationUpdates(provider, 0, 0f, locationListener)
    }
    override fun onStop() {
        unregisterReceiver(receiver)
        locationManager.removeUpdates(locationListener)
        super.onStop()
    }

    // ✓ Modern: use Lifecycle-aware components — auto-unregisters
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(LocationObserver(locationManager))
        // LocationObserver implements DefaultLifecycleObserver
        // registers in onStart(), removes in onStop() automatically
    }
}
07 · Leak Type

Coroutine & Scope Leaks

Coroutines running in the wrong scope can leak entire coroutine contexts, suspend functions waiting on callbacks, and all objects captured in their closure. This is the most modern and most underestimated category of leak.

Coroutine scope leaks
LEAK
class DataFragment : Fragment() {

    // ✗ GlobalScope — never cancelled, outlives Fragment
    fun loadData() {
        GlobalScope.launch {
            val data = repo.fetchData()          // Fragment destroyed mid-flight?
            binding.textView.text = data.title   // NPE or view reference on dead Fragment
        }
    }

    // ✗ Custom CoroutineScope not cancelled
    private val scope = CoroutineScope(Dispatchers.Main)
    // Missing: scope.cancel() in onDestroyView/onDestroy

    // ✗ Collecting flow with lifecycleScope without repeatOnLifecycle
    override fun onViewCreated(view: View, savedState: Bundle?) {
        lifecycleScope.launch {
            viewModel.flow.collect { updateUI(it) }  // collects even in background!
        }
    }
}
Correct scope usage
FIX
class DataFragment : Fragment() {

    // ✓ viewModelScope — cancelled in onCleared() automatically
    // ✓ lifecycleScope — cancelled in onDestroy() automatically
    // ✓ viewLifecycleOwner.lifecycleScope — cancelled in onDestroyView()

    override fun onViewCreated(view: View, savedState: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { render(it) }
                // stops collecting when STOPPED, resumes when STARTED
                // cancelled entirely in onDestroyView
            }
        }
    }

    // ✓ Custom scope: cancel it yourself
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    override fun onDestroyView() {
        scope.cancel()
        super.onDestroyView()
    }
}
08 · Leak Type

Bitmap & Drawable Leaks

Bitmaps are the largest objects in most Android apps. A single full-screen 2048×2048 image at ARGB_8888 consumes 16MB. Multiply that by cached thumbnails, background images, and recycled bitmaps improperly referenced — and you have the #1 source of OOM crashes.

Bitmap leak patterns
LEAK
// ✗ Custom View holding Bitmap without recycle
class AvatarView(context: Context) : View(context) {
    private var avatar: Bitmap? = null

    fun setImage(bmp: Bitmap) {
        avatar = bmp  // old bitmap never recycled if replaced
        invalidate()
    }
    // No onDetachedFromWindow() cleanup — bitmap lives forever
}

// ✗ Large Bitmap decoded on main thread without inSampleSize
val full = BitmapFactory.decodeResource(resources, R.drawable.hero)  // 40MB raw
Bitmap best practices
FIX
// ✓ Always use an image loading library (Coil, Glide, Picasso)
// They handle: caching, sampling, recycle, lifecycle-awareness
Coil: binding.imageView.load(url) {
    crossfade(true)
    size(ViewSizeResolver(binding.imageView))  // auto-samples to view size
}  // lifecycle-aware: cancels request when Fragment is destroyed

// ✓ If manual, clean up in onDetachedFromWindow
class AvatarView(context: Context) : View(context) {
    private var avatar: Bitmap? = null

    fun setImage(bmp: Bitmap) {
        avatar?.recycle()  // recycle old before replacing
        avatar = bmp; invalidate()
    }

    override fun onDetachedFromWindow() {
        avatar?.recycle(); avatar = null  // clean up when view removed
        super.onDetachedFromWindow()
    }
}
09 · Leak Type

Compose-specific Leaks

Jetpack Compose has a different memory model than Views, but it introduces its own leak patterns — particularly around effects, state, and the boundary between Compose and the traditional View system.

Compose leak patterns
LEAK
// ✗ DisposableEffect missing onDispose — observer never removed
@Composable
fun BadObserver(viewModel: MyViewModel) {
    DisposableEffect(viewModel) {
        viewModel.addListener(myListener)
        onDispose { }  // empty onDispose — listener never removed!
    }
}

// ✗ Capturing MutableState in a non-composable lambda that escapes
@Composable
fun BadState() {
    var count by remember { mutableStateOf(0) }
    SomeGlobalObject.listener = { count++ }  // state captured in global callback
    // Composition leaves → count state kept alive by global listener
}

// ✗ collectAsState() instead of collectAsStateWithLifecycle()
val state = viewModel.flow.collectAsState()  // keeps collecting in background
Compose fixes
FIX
// ✓ DisposableEffect with proper cleanup
@Composable
fun GoodObserver(viewModel: MyViewModel) {
    DisposableEffect(viewModel) {
        viewModel.addListener(myListener)
        onDispose { viewModel.removeListener(myListener) }  // always paired
    }
}

// ✓ Use collectAsStateWithLifecycle — stops when UI goes background
val state = viewModel.flow.collectAsStateWithLifecycle()

// ✓ Never capture Compose state in non-composable scopes
@Composable
fun GoodState(onEvent: () -> Unit) {  // pass callback up, not state out
    SomeWidget(onClick = onEvent)  // Compose UI never leaks into global objects
}
10 · Interactive

Heap Simulator

Watch your heap grow as you create leaks, observe GC events, and see what happens when the heap is exhausted. Each button simulates a real-world leak scenario.

HEAP MONITOR — LIVE
Heap Used
24 MB
Heap Max
128 MB
Leaked Objects
0
GC Events
0
Heap usage over time — MB
> Heap monitor started. Press leak buttons to simulate memory leaks.
11 · Diagnosis

Detection Tools

Finding memory leaks requires both automated tools and manual heap analysis. Here's the complete toolkit, ordered from easiest to most powerful.

🐤
LeakCanary
Automated · Zero setup
Automatically detects Activity, Fragment, ViewModel, and View leaks. Shows the exact reference chain keeping the object alive. Add one dependency — it works automatically in debug builds.
📊
Android Studio Profiler
Manual · Heap dumps
The Memory Profiler captures heap dumps, tracks allocations, and lets you inspect every object in memory. Force GC, capture before/after snapshots, and filter by class to find retained instances.
🔬
MAT (Eclipse Memory Analyzer)
Deep analysis · HPROF files
The most powerful heap analyzer. Open HPROF dumps from Android Studio. Find the dominator tree, shortest path to GC roots, and retained sizes. Overkill for simple leaks, essential for complex ones.
⌨️
adb shell dumpsys meminfo
CLI · Production monitoring
Quick snapshot of your app's memory breakdown: Java heap, native heap, code, stack, graphics. Run repeatedly while exercising the app — watch for steady growth that indicates a leak.
📈
Firebase Performance
Production · Monitoring
Track memory metrics in production across your entire user base. Set alerts for sustained high memory usage. Correlate with ANR and crash rates to quantify the real-world impact of leaks.
🔎
Strict Mode
Compile-time-ish · Dev only
Enable StrictMode.setVmPolicy() with detectLeakedClosableObjects(), detectLeakedSqlLiteObjects(). Crashes or logs when you forget to close streams, cursors, or SQLite connections — a common but boring leak category.
12 · Tool Deep Dive

LeakCanary Internals

LeakCanary is the most important tool in an Android developer's leak-detection arsenal. Understanding how it works helps you interpret its output and configure it for advanced use cases.

LeakCanary setup
// build.gradle.kts — add to debug dependencies only
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
// That's it. No Application.onCreate() code. ContentProvider auto-installs it.

// ── What LeakCanary does automatically ──
// 1. Hooks ActivityLifecycleCallbacks — watches every Activity
// 2. After onDestroy(), waits 5 seconds
// 3. Creates WeakReference to the Activity
// 4. Forces GC, checks if WeakReference was cleared
// 5. If not cleared → suspected leak → dumps HPROF
// 6. Analyzes heap in background → finds shortest GC root path
// 7. Shows notification with full leak trace

// ── Custom watched objects (beyond Activities) ──
class MyService : Service() {
    override fun onDestroy() {
        super.onDestroy()
        AppWatcher.objectWatcher.expectWeaklyReachable(this, "MyService destroyed")
    }
}

// ── Reading a LeakCanary trace ──
// ┬───────────────────────────────────────────
// │ GC Root: static field AnalyticsManager.INSTANCE
// │                                         ↓
// │ AnalyticsManager.context                ↓ (LEAK SUSPECT)
// │                                         ↓
// ╰→ MainActivity (5 instances, 48 MB retained)
// ┴───────────────────────────────────────────

LeakCanary threshold: The AppWatcher waits 5 seconds after onDestroy before checking. If the object was collected within those 5 seconds, no leak is reported. Only objects that survive a GC cycle are reported as actual leaks — very few false positives.

Reading leak traces

LeakCanary outputs a reference chain from the GC root to the leaked object. The leak suspect is the reference that shouldn't exist. Fix that reference (clear it in the right lifecycle callback, use WeakReference, or use Application context) and the leak disappears.

Retained size vs shallow size: When LeakCanary reports "48 MB retained", that's the total memory that would be freed if the leaked object were collected — including all objects only reachable through it. The shallow size is just the object itself. Always look at retained size to understand the real impact.

13 · Prevention

Prevention Checklist

Apply these rules consistently and you will eliminate 95% of memory leaks before they reach production.

Context rules

Use Application context in singletons, repositories, and any long-lived object that needs a Context
Never store Activity/Fragment reference in a ViewModel, singleton, or static field
Use @ApplicationContext with Hilt — it prevents accidentally injecting Activity context into long-lived components
Dismiss dialogs in onDestroy() — they hold window tokens that can outlive the Activity

Lifecycle rules

Null ViewBinding in onDestroyView() — the binding holds a reference to the entire view hierarchy
Use viewLifecycleOwner (not this) when observing LiveData in Fragments
Symmetric register/unregister — every listener registered in onStart() removed in onStop(). Every receiver registered in onResume() unregistered in onPause()
Cancel custom CoroutineScopes in the appropriate lifecycle callback (onDestroyView for view-related, onDestroy for component-related)

Coroutine rules

Never use GlobalScope — use viewModelScope, lifecycleScope, or a scoped CoroutineScope that you cancel
Use repeatOnLifecycle(STARTED) when collecting flows in Fragments — stops collection in background, prevents resource waste
Use collectAsStateWithLifecycle() in Compose — never plain collectAsState() for long-lived flows

Inner class rules

Make inner classes static (or top-level in Kotlin) — non-static inner classes capture this implicitly
Use WeakReference when a long-lived object must reference a short-lived one — but prefer restructuring so this isn't necessary
Replace Handler.postDelayed with lifecycleScope.launch { delay() } — the coroutine is automatically cancelled

Tooling rules

Add LeakCanary to every debug build — it's zero setup and catches the most common leaks automatically
Run the Memory Profiler on every major feature before shipping — navigate to each screen and back, then force GC and check retained heap
Monitor memory in CI with benchmark tests — catch regressions before they reach users by asserting maximum memory usage in automated tests

All leak types — quick reference

Leak type Severity Root cause Fix
Static Activity refCRITICALStatic field or singleton holds ActivityUse ApplicationContext or WeakReference
Non-static inner classCRITICALImplicit this captureMake static / top-level, use WeakReference
Context in ViewModelCRITICALViewModel outlives ActivityUse @ApplicationContext
Unregistered listenersHIGHRegister without matching unregisterSymmetric lifecycle calls
GlobalScope coroutinesHIGHCoroutine never cancelledUse viewModelScope / lifecycleScope
ViewBinding in FragmentHIGHBinding not nulled in onDestroyViewSet _binding = null in onDestroyView
Bitmap not recycledHIGHLarge objects held after useUse Coil/Glide, or recycle manually
Dialog not dismissedHIGHWindow token helddismiss() in onDestroy
Compose DisposableEffectMEDIUMEmpty onDisposeAlways pair setup with cleanup
collectAsState in ComposeMEDIUMCollects in backgroundUse collectAsStateWithLifecycle
StrictMode violationsMEDIUMUnclosed streams/cursorsClose in finally or use Closeable.use { }
Memory leaks are silent killers.

They don't crash immediately. They degrade slowly — the app gets sluggish, GC events get more frequent, frames get dropped. Then, after enough rotations or enough sessions, OutOfMemoryError. Add LeakCanary on day one. Fix leaks on day two.